Skip to content

Dashboard contract slice a#29

Open
juicycleff wants to merge 86 commits into
mainfrom
dashboard-contract-slice-a
Open

Dashboard contract slice a#29
juicycleff wants to merge 86 commits into
mainfrom
dashboard-contract-slice-a

Conversation

@juicycleff
Copy link
Copy Markdown
Contributor

No description provided.

juicycleff added 30 commits May 9, 2026 16:02
…plan

Slice (a) of the new declarative dashboard contract: single-endpoint API
with kind-discriminated envelope, intent + slots schema, server-side
permission filtering, multiplexed SSE for subscriptions, per-contributor
version negotiation. The React shell and contributor migrations are
separate slices.

DESIGN.md captures the locked-in design decisions; IMPLEMENTATION_PLAN.md
breaks the work into 15 phases of TDD-driven tasks.

Adds a .gitignore exception so design docs co-located with the package
are tracked despite the repo-wide **/*.md ignore.
The previous TestCanonicalCodes_AllPresent only verified that constants
are non-empty string literals — it could not fail short of someone
deleting a constant from the list itself. The replacement asserts the
expected count, uniqueness of wire values, and that every Err* sentinel
has a non-empty Code.
…ntKind

Phase 10's POST handler passes req.Kind (wire envelope type) directly into
Action.Kind when invoking a Warden. Action.Kind was inadvertently typed as
IntentKind (the manifest-side enum) which would have caused a type mismatch.
The wire Kind is the right boundary for the Warden — it sees the action as
the caller framed it, not as the registry classifies the intent.
…ss-contributor extensions

Phase 6 of Dashboard Contract slice (a):

- registry.go/registry_test.go: Registry interface (Register, Contributor,
  Intent, HighestVersion, All, MergedGraph). Indexed by (contributor, intent,
  version); HighestVersion tracks the highest non-deprecated version per
  (contributor, intent), falling back to a deprecated version only if no
  active version is registered.

- slots.go/slots_test.go: built-in slot catalog (DefaultSlotCatalog) with
  page.shell, resource.list, dashboard.grid, form.edit. SlotDef carries
  Accepts, Cardinality, and Extensible. MaxSlotDepth = 8. Registration
  runs checkDepth, checkCycle, validateGraphSlots over the manifest's graph.

- Cross-contributor slot extensions: applyExtension walks dotted slot paths
  (e.g. main.detailDrawer.fields), enforces the Extensible flag, and validates
  added intents against the target slot's Accepts list. Each Register call
  deep-copies the manifest's graph into r.mergedGraphs[contributor], then
  applies its extends: directives against the merged graph of the target
  contributor — so extensions never mutate original manifests.
Adds an optional Contract *contract.ContractManifest field to the legacy
contributor.Manifest so a contributor can publish a contract-style
manifest in parallel with the existing templ-based one. Tagged
"contract,omitempty" so legacy contributors that don't opt in keep their
JSON payloads unchanged.

Round-trip + omitempty tests exercise the new field.
Hooks the contract package into the running dashboard extension:

- New Extension fields hold a contract.Registry, contract.WardenRegistry,
  and contract.AuditEmitter, all initialised in NewExtension. The
  streamBroker stays nil in slice (a); slice (c) provides the
  SubscriptionSource needed to instantiate it.
- registerRoutes now mounts POST /api/dashboard/v1 and
  GET /api/dashboard/v1/capabilities alongside the existing JSON API.
  When a streamBroker is present, the SSE stream + control routes also
  register. The stream route uses router.GET (not router.EventStream)
  because StreamBroker.ServeStream owns its own SSE framing as an
  http.HandlerFunc, while router.EventStream's SSEHandler shape
  (func(Context, Stream) error) doesn't match.
- RegisterContributor and the auto-discovery + remote-upsert paths now
  mirror any contract manifest published on the legacy Manifest.Contract
  field into the contract registry, validating against the warden
  registry first via loader.Validate. Explicit-add paths fail closed
  (validation error rolls back the legacy registration); auto-discovery
  logs and continues.

CSRF + idempotency header validation and a real Dispatcher are deferred
to slices (b) and (c); slice (a) wires transport.NilDispatcher so every
intent dispatch returns CodeUnavailable instead of nil-panicking.
…design

Three-tier dispatcher API (function table + contributor interface +
generic typed wrappers), narrow handler signature with optional Result,
subscription handlers via channel + stop, MetricsEmitter interface for
slice-b observability wiring, and a P2 pilot scope: extensions.list,
services.list, services.detail, and a metrics.cpu replace-mode
subscription wired against the existing collector.DataCollector.
Subscribes to a subscription intent declared in node.data, buffers the
last props.bufferSize events (default 200), and renders them in a
scrollable monospace pane inside a Card. Auto-scroll-to-bottom unless
the user has scrolled up — sticky-bottom UX matches admin-tool conventions
for log tails. Two display modes:
- 'json' (default): JSON.stringify the payload
- 'line': pluck the .line field for raw text feeds

Adds shadcn ScrollArea primitive (Radix-based) for the scroll region.
README expanded from quickstart-only to a full developer-facing guide:
project structure with per-file purposes, theming overview, embedding
explanation, and pointer to ARCHITECTURE.md for the deep dive.

ARCHITECTURE.md is the new deep-dive: pipeline diagram, the five core
concepts (graph, registry, slots, contributor/parent contexts,
bindings), step-by-step walkthrough for authoring a new intent (with
real code), guidance on adding shadcn primitives, testing strategy,
performance budget, known limitations, and rationale for the major
tech choices (shadcn vendored, TanStack Query, Zustand, CSS variables).

Adds a .gitignore exception so shell/*.md are tracked alongside the
already-tracked design + plan docs.
…Base UI

Per directive: 'use sadcn baseui and not radix.' shadcn ships parallel
Radix-based and Base UI-based variants of the same components. This
slice swaps the dashboard shell to the Base UI variant
(@base-ui-components/react). Public component imports (@/components/ui/*)
and the v1 vocabulary are unchanged; only the primitive layer
underneath shifted.

Removed: @radix-ui/react-{slot,separator,dropdown-menu,dialog,avatar,
scroll-area,select,checkbox,label,alert-dialog}.
Added: @base-ui-components/react@1.0.0-rc.0.

Rewrites in src/components/ui/ — public component names and prop
shapes preserved by the wrappers:
- button.tsx — local Slot helper for asChild (Base UI has no Slot
  primitive; uses render prop instead).
- separator.tsx — Base UI Separator.
- avatar.tsx — Avatar.Root / .Image / .Fallback.
- dropdown-menu.tsx — Menu façade (Base UI calls it Menu, not
  DropdownMenu). Trigger wrapper translates asChild → render.
- alert-dialog.tsx — AlertDialog with Trigger asChild→render.
- sheet.tsx — Dialog with side variants and Trigger asChild→render.
- scroll-area.tsx — ScrollArea.Root / .Viewport / .Scrollbar / .Thumb.
- checkbox.tsx — Checkbox.Root / .Indicator. onCheckedChange unchanged.
- label.tsx — plain <label> + cva (Base UI doesn't ship Label).

card.tsx, alert.tsx, skeleton.tsx, table.tsx, input.tsx, textarea.tsx
were pure-styling primitives — no change.

Form test updated: Base UI Checkbox renders span[role=checkbox] with
a Base-generated id, so getByLabelText doesn't traverse via htmlFor.
Test now uses getByRole('checkbox').

Bundle: ~140KB gzipped JS (was ~120KB on Radix). Within the 300KB
budget. CSS unchanged at 5KB. All 23 tests + lint + Go suite green.
- Added auto-discovery for ContractContributorAware in the dashboard extension to register contract contributors.
- Introduced new streaming contract handlers for managing channels, connections, rooms, and presence.
- Created manifest.yaml for the streaming contract, defining intents and capabilities.
- Implemented various query and mutation handlers for stats, connections, rooms, and presence management.
- Enhanced the streaming extension to support both legacy and new contract paths during migration.
contract_test.go exercises stats, kick-connection, delete-room,
send-message, set-presence, config, and presence-list handlers via a
stub Manager. Covers the canonical-error mapping (BadRequest,
NotFound, Unavailable) for each mutation.

manifest_test.go loads the embedded manifest.yaml and validates it
against an empty WardenRegistry. Asserts contributor name,
≥14 declared intents (9 reads + 5 mutations), and 6 routes.
… CoreContributor pages

Extends the pilot manifest and handlers to cover the four CoreContributor pages
the templ dashboard serves today: Overview, Health, Metrics report, and Traces.

- types.go: add OverviewResponse, HealthList/Entry, MetricsReportResponse,
  TraceSummaryDTO, TracesList, TraceDetailInput, TraceDetailResponse.
- overview.go, health.go, metrics_report.go, traces.go: query handlers
  projecting collector data into the wire types; nil providers return
  CodeUnavailable; trace.detail returns CodeNotFound on miss and
  CodeBadRequest on empty id.
- manifest.yaml: register five new intents (overview, health, metrics-report,
  traces.list, traces.detail) and four new graph routes (/, /health, /metrics,
  /traces) under the existing core-contract contributor.
- pilot.go: new Deps fields (Overview, Health, MetricsReport, Traces) wired
  into Register; partial wiring tolerated.
- extension.go: pass e.collector and e.traceStore as the new providers.
- slice_h_test.go: unit tests for each handler covering happy path,
  CodeUnavailable, CodeNotFound, and CodeBadRequest error mapping.
…ct to React shell

The contract React shell (slice d) plus the slice-(h) pilot now serve every
page CoreContributor provided. This slice removes the duplicate templ stack
and forwards old paths to /dashboard/contract/app/* via 302 so existing
bookmarks keep working.

Removed:
- extensions/dashboard/core_contributor.go (CoreContributor + manifest +
  RenderPage/RenderWidget/RenderSettings)
- NewCoreContributor registration in extension.go
- Ten CoreContributor-only templ pages and their _templ.go artifacts:
  overview, health, metrics, metrics_all, metrics_collector_detail,
  metrics_detail, services, extensions, traces, trace_detail
- The eight matching *_helpers.go files that fed them
- The ten templ-rendering page methods on PagesManager (~250 LOC)

Added:
- redirectTo / redirectTraceDetail helpers in pages.go that 302 to the
  shell. /metrics/all, /metrics/collectors/:name, /metrics/detail/*name
  collapse onto /contract/app/metrics; slice (j) adds proper deep-link
  routes when those detail pages get rebuilt React-side.
- extensions/dashboard/contract/SLICE_I_DESIGN.md

Kept (still used by extension contributors):
- ui/shell/, layouts/, ui/components.templ, ui/widgets.templ,
  ui/metrics.templ, ui/tables.templ, ui/pages/error.templ.

Net: ~15k LOC of generated templ + helpers removed; one contract pilot
+ one React shell remain. go build ./... + go test ./... clean.
…:id deep-link

The contract React shell never actually fetched live graphs — POST kind=graph
404'd because page.shell isn't a registered intent (it's a vocabulary marker
for slot validation). The slice (d) shell tests passed by mocking the response.
This slice wires the graph endpoint end-to-end and lays the foundation for
deep-link detail routes.

Server:
- transport/http.go: special-case kind=graph in ServeHTTP — bypass the intent
  table and dispatch via GraphBuilder.BuildWithParams. Map ErrNotFound /
  ErrPermissionDenied to wire codes + matching HTTP status. CSRF and
  idempotency stay command-only as before.
- registry.go: new Registry.MatchRoute(contributor, route) that adds :name
  pattern matching alongside existing exact matching. Exact wins when both
  match. Returns extracted name->value params.
- graph.go: GraphBuilder.BuildWithParams threads params through the
  visibleWhen filter; legacy Build() delegates.
- envelope.go: ResponseMeta.RouteParams carries the extracted segments.
- pilot/manifest.yaml: add /traces/:id deep-link route binding traces.detail
  with id from route.id. Slice (h)'s /traces drawer pattern stays.
- pilot/graph_test.go: new HTTP-level tests covering exact match, :id
  extraction, NotFound mapping, and exact-wins-over-param ordering.

Shell:
- contract/client.ts: graph() now returns { node, routeParams }; new
  sendEnvelope() variant returns the full Response so meta is reachable.
- contract/hooks.ts: useContractGraph returns GraphResult.
- runtime/context.tsx: new RouteParamsProvider + useRouteParams.
- App.tsx: wraps the renderer with RouteParamsProvider so :name placeholders
  flow into bindings.
- intents/{resource.detail,action.button,action.menu,form.edit}.tsx: pass
  route into resolvePayload/resolveValue so `{ from: route.id }` payloads
  resolve when the user lands on /traces/abc directly.
- contract.test.ts: extended for new shape; new test covers route param
  surfacing.

Slice (i) cleanup:
- pages.go: redirectTraceDetail now forwards /dashboard/traces/:id to
  /contract/app/traces/:id (path-style) instead of ?id=… query string.

go build ./... + go test ./... + pnpm test (24 React tests) + pnpm build clean.
…ubscription

Closes two related gaps:

1. The audit emitter (slice b) wrote to stdout/Logger and disappeared. There
   was no way for the dashboard to surface audited commands.
2. The audit.tail vocabulary intent + React component (slice e) had no
   matching server-side handler — clicking the audit widget produced
   nothing.

Added:
- contract/audit_store.go:
  - AuditStore interface + AuditFilter (Limit/Contributor/Intent/User/Result).
  - In-memory ring-buffer impl (default cap 1000), fan-out subscriptions,
    non-blocking sends so a slow consumer can't block command writes.
  - RecordingAuditEmitter that wraps an inner emitter (existing log-based
    one) and persists to the store.
- contract/pilot/audit.go:
  - AuditProvider interface + AuditRecordDTO with RFC3339Nano timestamps.
  - audit.list query handler (filters + nil-store -> CodeUnavailable).
  - audit.tail subscription handler (streams Append events; cancellation
    tears down the subscriber cleanly).
- pilot/manifest.yaml:
  - Two new intents (audit.list query, audit.tail subscription/append).
  - auditList named query with 5s staleTime cache.
  - New /audit route under Operations nav rendering audit.tail.
- contract/slots.go: extend page.shell.main Accepts to include audit.tail
  so the new top-level route validates.
- extension.go:
  - Construct e.auditStore at NewExtension time.
  - Wrap the chosen audit emitter (log/structured) with RecordingAuditEmitter
    so commands flow into both log lines AND the store.
  - Pass e.auditStore as pilot.Deps.Audit.

Tests:
- audit_store_test.go: append/list ordering, intent filter, limit clamping,
  ring truncation, subscribe broadcast, cancel-closes-channel,
  RecordingEmitter fan-out.
- pilot/audit_test.go: handler projection (timestamp formatting),
  CodeUnavailable on nil provider, subscription streams Appends,
  CodeUnavailable from subscription handler.
- pilot/types_test.go: bumped manifest counts (intents 9->11, routes 8->9).

go build + go test ./... clean (37 packages green).
…o non-default base paths work

Reported: hosting Forge on port 7901 with the dashboard mounted at a
non-default base produced 404s on /api/dashboard/v1 because the React shell
hardcoded both that path and the React Router basename to /dashboard.

Server: extension.go's makeShellSPAHandler now buffers index.html and
injects a small <script> just before </head> that exposes
window.__FORGE_DASHBOARD__ = { basePath, contractBase, shellBase }, derived
from e.config.BasePath. Shell:

- runtime/config.ts (new): reads the injected globals at module load and
  exports basePath / contractBase / shellBase. Falls back to /dashboard so
  Vite dev mode + unit tests + direct module imports still resolve.
- contract/client.ts, contract/sse.ts: default baseURL now comes from
  contractBase instead of the hardcoded literal.
- auth/principal.ts: /principal fetch is `${contractBase}/principal`.
- App.tsx: BrowserRouter basename comes from shellBase.

Test setup pins __FORGE_DASHBOARD__ to /api/dashboard/v1 so the existing
MSW handlers keep matching.

24 React tests + 37 Go packages green; pnpm lint and pnpm build clean.
…l login gate

Replaces the bare GraphRenderer mount with a proper shadcn-style dashboard
layout (sidebar + topbar + content) and adds an optional login gate that
auth extensions like authsome can plug into without writing React.

## Layout

- Vendored shadcn's `<Sidebar>` block on the existing Base UI primitives:
  new `components/ui/sidebar.tsx` exposing the canonical SidebarProvider /
  Sidebar / SidebarMenu / SidebarTrigger / SidebarInset / useSidebar API,
  built on Sheet (mobile drawer), local Slot helper (asChild), cookie
  persistence, and Cmd+B shortcut. Plus `components/ui/breadcrumb.tsx`.
- New `runtime/layout.tsx::DashboardLayout` composes the chrome: sidebar
  with nav groups, topbar with breadcrumb + theme toggle, sidebar footer
  with user badge.
- `runtime/navigation.ts::useNavigation` calls a new contract query.
- `App.tsx::PageRoute` wraps the renderer in DashboardLayout; `page.shell`
  intent simplifies to render only its main slot (chrome moved up).
- Tailwind + index.css gain the shadcn `--sidebar-*` tokens (light + dark).
- jsdom test setup polyfills `matchMedia` for the new mobile-breakpoint
  hook.

## Navigation pilot query

- `contract/pilot/navigation.go` walks `Registry.All()`, projects each
  contributor's top-level routes whose graph node carries Nav metadata,
  groups by Nav.Group, sorts groups by the same priority order the legacy
  templ sidebar uses, sorts items within a group by Nav.Priority.
- Manifest: registers `navigation` query intent (capability=read), 60s
  staleTime cache. Wired through `pilot.Register`.
- Tests cover group ordering, in-group priority sort, skipping routes
  without Nav (e.g. /traces/:id detail), nil registry => CodeUnavailable.

## Optional login gate

- Principal endpoint refactored to `NewPrincipalHandler(opts)` distinguishing
  three responses:
  - 200 `{authenticated:false}` when auth is disabled (shell skips gate).
  - 401 `{code:"UNAUTHENTICATED",loginPath:...}` when enabled but unauthed.
  - 200 with full principal when authenticated.
  Backwards-compatible `HandleAPIPrincipalHTTP` preserved.
- Shell `usePrincipalStore` adds `authRequired` + `loginPath` derived from
  the new envelope shapes.
- `auth/AuthGate.tsx` blocks the layout while the principal loads, then
  passes through (auth disabled or signed in) or replaces the tree with
  the built-in `LoginScreen`.
- `auth/LoginScreen.tsx` is a minimal email+password form that issues a
  `kind: command, intent: <loginOp>` envelope (default `auth.login`,
  configurable via `window.__FORGE_DASHBOARD__.loginOp`). On 200 it
  reloads the principal so the gate releases.
- Server bootstrap injection (extension.go) extends the existing slice (i)
  config map with `authEnabled`, `loginPath`, `loginOp`. Shell config.ts
  surfaces them as `authEnabled`, `loginPath`, `loginOp` exports.
- aware.go documents the authsome integration shape: implement
  DashboardAuthAware (SetAuthChecker + EnableAuth) plus
  ContractContributorAware (register `auth.login` command + optional
  `/login` graph route to override the built-in form).

## Tests

Go: navigation_test.go (3 cases), principal_test.go (+2 cases for
auth-disabled vs auth-enabled responses).
React: auth.test.tsx (5 cases — gate pass-through, gate redirects,
LoginScreen happy path, error path, principal store envelope handling),
layout.test.tsx (2 cases — sidebar nav groups render from contract,
breadcrumb fallback).

Manifest counts bumped (intents 11 -> 12).

go build + go test ./... clean (37 packages green); pnpm test 32 pass;
pnpm lint clean; pnpm build clean.
…in route, polish built-in form

Two follow-ons to slice (l):

1. The auth extension now owns the login UI by default. AuthGate fetches
   useContractGraph(loginContributor, "/login") before falling back to the
   built-in LoginScreen. authsome (or any extension) registers a single
   `/login` graph route under its contributor (default "auth") to take
   over — no React code, just YAML + a command intent.
   - extension.go: bootstrap config gains `loginContributor: "auth"`.
   - runtime/config.ts: surfaces `loginContributor` (default "auth").
   - auth/AuthGate.tsx: when authRequired, fetches the contract /login
     graph; renders it via GraphRenderer wrapped in
     ContributorProvider/RouteParamsProvider when the extension owns it.
     404 / no graph → built-in LoginScreen.

2. Built-in LoginScreen polished to match the latest shadcn login-03 layout
   that ships with the Sidebar block from ui.shadcn.com:
   - Centered card on a subtle bg-muted/40 background.
   - Brand lockup above the card (LayoutDashboard icon + "Forge Dashboard"
     by default; override via prop).
   - "Welcome back" heading + concise description copy.
   - Email/password inputs with placeholder, "Forgot password?" link
     beside the password label, full-width primary submit button.
   - Inline error block with AlertCircle icon for failed auth.
   - Footer caption beneath the card.

Tests: AuthGate test split into three cases — pass-through, contract-owned
/login override, fallback to built-in form on 404. 33 React tests green.
…handlers via ctx

Auth extensions registering an `auth.login` command via the contract path
need to write a session cookie on the HTTP response — the contract handler
shape (in, principal -> out, error) doesn't carry the writer. Slice (l)'s
AuthGate routes login through the contract; the command handler had no
way to set the cookie.

Adds two small dashauth helpers:
  - WithHTTP(ctx, w, r) ctx
  - ResponseWriterFromContext(ctx) http.ResponseWriter
  - RequestFromContext(ctx) *http.Request

The contract transport handler now stashes both before dispatching, so
authsome (and any future extension that legitimately needs HTTP escape
hatches — downloads, redirects) can pull them via dashauth.

Pure data handlers ignore them. Documented as an escape hatch.
… + role gate

Brings the dashboard's login UX up to the latest shadcn login-04 reference
and adds two integration seams the auth extension uses to drive the form:

1. **Visual layer (LoginCard).** New `auth/login-card.tsx` is the canonical
   login surface — brand lockup, "Welcome to {brand}" heading, optional
   "Don't have an account? Sign up" line, email + Login button, "Or"
   separator, social provider button grid (Apple/Google/GitHub/Microsoft/
   Facebook/Discord glyphs ship inline; unknown providers fall back to a
   neutral circle), terms/privacy footer. Styled via the existing primitives
   plus a new shadcn-style `field.tsx` (Field/FieldGroup/FieldLabel/
   FieldDescription/FieldSeparator) that other shadcn blocks slot into.

2. **AuthLoginForm intent (`auth.login.form`).** New React component
   registered in the intent registry — the dashboard's contract `/login`
   route renders this. It fetches an `auth.config` query from the active
   contributor (authsome) and projects the result into LoginCard.
   Authsome owns brand, signup link, terms/privacy, and the configured
   list of social providers; the shell renders whatever ships, no React
   change required when authsome enables a new provider.

   Submits the password block via the existing `kind:command` envelope
   to the configured loginOp. Social buttons POST to each provider's
   `authStartURL` (returned by auth.config) and navigate to the upstream
   `auth_url`, matching authsome's social plugin start-flow contract.
   `slots.go` declares `auth.login.form` as a leaf vocabulary intent.

3. **Built-in fallback aligned.** LoginScreen now renders LoginCard with
   hardcoded defaults so deployments without an auth extension still get
   the polished login-04 visual. Visuals are byte-identical to the
   contract-rendered path; only the data plane differs.

4. **Required-roles gate.** New `WithRequiredRoles([]string)` option +
   `Extension.SetRequiredRoles` API. The principal handler returns 403
   `{code:"PERMISSION_DENIED", message, requiredRoles}` for users who
   don't carry a matching role. React `usePrincipalStore` adds
   `accessDenied` / `accessDeniedMessage` / `requiredRoles`; AuthGate
   renders an "Access denied" panel (ShieldAlert + message + retry button)
   when the gate fires. Auth extensions like authsome wire it via
   `SetRequiredRoles` from their own role config so dashboards can be
   locked down to specific user populations without writing handler code.

Tests:
- React: 9 auth tests (was 7) — added access-denied panel rendering,
  store handling of 403 envelope, plus the fallback LoginScreen still
  exercising password command flow with the new "Login" button label.
- Go: principal handler tests cover 200 anonymous, 401 unauth, 403
  required-role gate; transport + pilot suites unaffected.
- 35 React tests / 15 Go dashboard packages green; lint + build clean.

Authsome wires `auth.config` + `auth.login.form` /login route in a
companion commit on the authsome repo.
…e aggregation

Mirrors the legacy templ dashboard's remote-contributor capability on the
new contract path. A single dashboard can now aggregate contributors from
multiple upstream services; intents resolve to local handlers when present
and forward over HTTP to the registered upstream when not — the transport
layer doesn't know the difference.

## Registry (contract/registry.go)

- New RemoteEndpoint{BaseURL, APIKey, Client} describes how to reach an
  upstream contributor.
- Registry interface gains RegisterRemote, IsRemote, Remote, Unregister.
- RegisterRemote runs the same validate/merge path Register does so remote
  manifests participate in MergedGraph/MatchRoute uniformly; the endpoint
  is recorded separately for the forwarding layer to look up at dispatch
  time.
- Unregister tears down all per-contributor state (intents, highest map,
  merged graph, remote endpoint) — used by future discovery loops when an
  upstream goes offline.

## Dispatcher (contract/dispatcher/dispatcher.go)

- New RemoteDispatcher interface + Dispatcher.SetRemoteDispatcher.
- dispatchInner: local lookup → remote fallback (if set) → CodeNotFound.
  Remote calls are wrapped in the same metrics + error-code mapping as
  local handlers so observability stays uniform.

## contract/remote (new package)

- ForwardingDispatcher implements RemoteDispatcher by POSTing the verbatim
  envelope to <RemoteEndpoint.BaseURL>/_forge/contract/dispatch.
- Forwards inbound Authorization/Cookie headers as X-Forwarded-* so the
  upstream sees the end-user identity; sends Authorization: Bearer
  <APIKey> as the dashboard's own service-to-service credential
  (mirrors the legacy RemoteContributor pattern).
- Surfaces upstream contract errors verbatim; maps dial failures to
  CodeUnavailable. Decode failures from the upstream get CodeInternal.
- FetchManifest helper hits <baseURL>/_forge/contract/manifest, decodes
  the response, validates contributor.name is present.

## contract/server (new package)

- Helper any Forge service can use to advertise itself as a contract
  contributor. Mounts:
    GET  /_forge/contract/manifest[?contributor=NAME]
    POST /_forge/contract/dispatch
- Manifest endpoint serves the single registered manifest directly when
  exactly one contributor is registered (legacy-shape compatible);
  multiple contributors get the {manifests:[...]} catalog wrapper.
- Dispatch endpoint reuses transport.NewHandler so envelope parsing,
  CSRF/idempotency, kind/capability matching, warden checks, and audit
  emission are identical to the dashboard's own /api/dashboard/v1.

## Dashboard extension (extensions/dashboard/extension.go)

- New Extension.RegisterRemoteContractContributor(ctx, baseURL, apiKey)
  fetches + validates the upstream manifest, calls
  contractRegistry.RegisterRemote, and idempotently installs a
  ForwardingDispatcher on first call.
- New Extension.UnregisterRemoteContractContributor for tear-down.
- forwardingInstalled flag + forwardingMu guard ensure the forwarding
  dispatcher is wired exactly once per dashboard.

## Tests (16 new)

- Registry: RegisterRemote round-trips endpoint + manifest state;
  locals are not flagged remote; missing BaseURL is rejected;
  Unregister clears everything; unknown name is noop.
- Dispatcher: local handler preferred over remote; falls through to
  remote when local missing; upstream errors surfaced verbatim;
  CodeNotFound when neither knows.
- ForwardingDispatcher: CodeNotFound when contributor isn't remote;
  success envelope round-trips data + meta; upstream error envelope
  surfaces verbatim; auth headers forwarded (user via X-Forwarded-*,
  dashboard via Authorization: Bearer); dial failure → CodeUnavailable.
- FetchManifest: decodes JSON, non-2xx surfaces status + body excerpt,
  missing contributor.name rejected.
- Server: manifest endpoint returns single manifest by default,
  ?contributor=X selects, /dispatch round-trips, unknown path is 404.
- E2E round-trip: host dispatcher with ForwardingDispatcher → upstream
  Server → through-dispatch returns the upstream's data; verifies the
  full slice works end-to-end.

go build ./... + go test ./... clean (39 packages green).

## Out of scope (slice m2)

- Auto-discovery via the forge service registry (mirror legacy
  extensions/dashboard/discovery/integration.go).
- Subscription forwarding (cross-service SSE multiplexing) — currently
  CodeNotFound on the fallback path for kind=subscribe.
- Remote-side caching / stale-on-error fallback.
- Per-contributor warden authorization on the dispatch path (the
  upstream applies its own warden; the host doesn't double-check).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant